<?php
/**
 * Module: Away
 * 
 * This module provides functionality for !away and !back. Designed to support 
 * both file or database as storage, however database-support is not built yet.
 * 
 * Configuration: Away
 * Module: Away
 * 
 * This configuration directive is required for the Away module. The triggers 
 * for all available commands can be changed here. Also options for general 
 * behaviour are available, such as:
 * 
 * StripFormat:   this will strip all colors, bolds and underlines from the away
 *                reason
 * ShortDuration: this will display the time difference as '1h 2m 6s' in stead
 *                of '1 hour, 2 minutes and 6 seconds'
 * Storage:       in here you can setup the file or database to use for storage 
 *                (currently only file storage is supported)
 *
 * 
 * 'Away' => array
 * (
 * 	'Triggers' => array
 * 	(
 * 		'Away'      => '!away',
 * 		'Back'      => '!back',
 * 		'Restore'   => '!restoreaway',
 * 		'List'      => '!afk',
 * 		'Records'   => '!awaytop3',
 * 	),
 * 	
 *      // Colors, bold, underline, etc
 * 	'StripFormat'   => true,
 *      // Use 1h 2m 6s in stead of 1 hour, 2 minutes and 6 seconds
 * 	'ShortDuration' => false,
 * 	'Storage'       => array
 * 	(
 *              // Only 'file' at the moment
 * 		'Type'      => 'file',
 * 		'Filename'  => 'Data/Away.dat',
 * 	),
 * ),
 * 
 * @author MrBondt <mrbondt@goldenak.eu>
 */
class Away extends ModuleBase {
	/**
	 * This constant indicates we use a plaintext file to store the away 
	 * entries.
	 */
	const MODE_FILE = 1;
	
	/**
	 * This constant indicates we use the database to store the away entries
	 * for a long period of time.
	 */
	const MODE_DATABASE = 2;
	
	/**
	 * The way the away entries are stored.
	 */
	private $m_iMode;
	
	/**
	 * Associative array with all the triggers this module responds to.
	 */
	private $m_aTriggers;
	
	/**
	 * A boolean which indicates whether the formatting should be stripped
	 * from away reasons.
	 */
	private $m_bStripFormat;
	
	/**
	 * A boolean which indicates whether we want short notation of the times,
	 * or full blown texts.
	 */
	private $m_bShortDuration;
	
	/**
	 * A cache of the away people.
	 */
	private $m_aAwayCache;
	
	/**
	 * The constructor processes the configuration and loads up all the
	 * people who are away.
	 * 
	 * @throws Exception When no configuration was found.
	 */
	public function __construct () {
		$aConfig = Configuration :: getInstance () -> get ("Away");
		
		if (empty ($aConfig)) {
			throw new Exception ('No configuration found for module "Away". This module will not be available.');
		}
		
		$this -> m_aTriggers = $aConfig ['Triggers'];
		$this -> m_bStripFormat = $aConfig ['StripFormat'];
		$this -> m_bShortDuration = $aConfig ['ShortDuration'];
		
		switch ($aConfig ['Storage']['Type']) {
			case 'file':
				$this -> m_iMode = Away :: MODE_FILE;
				break;
				
			case 'database':
				$this -> m_iMode = Away :: MODE_DATABASE;
				break;
		}
		
		$this -> load ();
	}
	
	/**
	 * The destructor quickly saves everything before the object is
	 * destroyed. Well, it did in the past anyway.
	 */
	public function __destruct () {
		//$this -> save ();
	}
	
	/**
	 * The onConnect() callback checks if the needed dependencies are
	 * available.
	 * 
	 * @param Bot $pBot The bot who received the connect message.
	 */
	public function onConnect (Bot $pBot) {
		if (!class_exists ('Func')) {
			echo 'Module "Func" not loaded. The module "Away" requires this module.' . PHP_EOL;
			echo 'Therefore the module "Away" will not be available.' . PHP_EOL;
			
			ModuleManager :: getInstance () -> unloadModule ('Away');
		}
	}
	
	/**
	 * Enable the commands in channels.
	 * 
	 * @param Bot $pBot The bot who received the public channel message.
	 * @param string $sChannel Channel in which we received the message.
	 * @param string $sNickname The nickname associated with this message.
	 * @param string $sMessage And of course the actual message we received.
	 */
	public function onChannelPrivmsg (Bot $pBot, $sChannel, $sNickname, $sMessage) {
		$this -> handleCommand ($pBot, $sChannel, $sMessage);
	}
	
	/**
	 * Enable the commands in private messages.
	 * 
	 * @param Bot $pBot The bot who received the public channel message.
	 * @param string $sNickname The nickname associated with this message.
	 * @param string $sMessage And of course the actual message we received.
	 */
	public function onPrivmsg (Bot $pBot, $sNickname, $sMessage) {
		$this -> handleCommand ($pBot, $sNickname, $sMessage);
	}
	
	/**
	 * Enable the commands in notices.
	 * 
	 * @param Bot $pBot The bot who received the notice.
	 * @param string $sChannel Channel in which we received the message.
	 * @param string $sNickname The nickname associated with this message.
	 * @param string $sMessage And of course the actual message we received.
	 */
	public function onNotice (Bot $pBot, $sChannel, $sNickname, $sMessage) {
		$this -> handleCommand ($pBot, $sNickname, $sMessage);
	}
	
	/**
	 * This method loads up all the away entries, so we get the best performance,
	 * no matter what storage type is used. Returns a boolean which indicates 
	 * whether the load has succeeded.
	 * 
	 * @return boolean
	 */
	public function load () {
		$aConf = Configuration :: getInstance () -> get ("Away");
		
		switch ($this -> m_iMode) {
			case Away :: MODE_FILE:
				if (!file_exists ($aConf ['Storage']['Filename'])) {
					return false;
				}
				
				$aLines = file ($aConf ['Storage']['Filename'], FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
				foreach ($aLines as $sLine) {
					list ($sName, $bIsBack, $tsTime, $sReason) = explode (',', $sLine, 4);
					
					$this -> m_aAwayCache [$sName] = array
					(
						'Nickname' => $sName,
						'IsBack'   => (bool) $bIsBack,
						'Time'     => (int) $tsTime,
						'Reason'   => $sReason,
					);
				}
				break;
				
			case Away :: MODE_DATABASE:
				return false;
				break;
		}
		
		return true;
	}
	
	/**
	 * This method saves all current entries to the specified storage method.
	 * Returns a boolean which indicates whether the save has succeeded.
	 * 
	 * @return boolean
	 */
	public function save () {
		$aConf = Configuration :: getInstance () -> get ("Away");
		
		switch ($this -> m_iMode) {
			case Away :: MODE_FILE:
				$rFile = fopen ($aConf ['Storage']['Filename'], 'w');
				
				foreach ($this -> m_aAwayCache as $aInfo) {
					fwrite ($rFile, $aInfo ['Nickname'] . ',' . $aInfo ['IsBack'] . ',' . $aInfo ['Time'] . ',' . $aInfo ['Reason'] . PHP_EOL);
				}
				
				fclose ($rFile);
				break;
				
			case Away :: MODE_DATABASE:
				return false;
				break;
		}
		
		return true;
	}
	
	/**
	 * The central place where all IRC commands of this module get executed.
	 * 
	 * @param Bot $pBot The bot who received the command.
	 * @param string $sDestination The place where the output should be sent to (channel or name).
	 * @param string $sMessage The message which has been received.
	 */
	private function handleCommand (Bot $pBot, $sDestination, $sMessage) {
		$sNickname = $pBot -> In -> Nickname;
		if (strpos ($sMessage, ' ') !== false) {
			list ($sTrigger, $sParams) = explode (' ', $sMessage, 2);
			$sParams = trim ($sParams);
		} else {
			$sTrigger = $sMessage;
			$sParams = null;
		}
		$sTrigger = strtolower ($sTrigger);
		
		switch ($sTrigger) {
			case $this -> m_aTriggers ['Away']:
				$sParams = $this -> reason ($sParams);
			
				if ($this -> isAway ($sNickname)) {
					$aPrev = $this -> updateAway ($sNickname, $sParams);
					$this -> send (
						$pBot,
						$sDestination,
						$aPrev ['Nickname'] . ' was ' . $this -> duration ($aPrev ['Time']) . 
						' away for ' . $aPrev ['Reason'] . ' ' .
						'and is now leaving again for ' . $sParams . '.'
					);
					
					$this -> save ();
				} else {
					$this -> setAway ($sNickname, $sParams);
					$this -> send (
						$pBot,
						$sDestination,
						$sNickname . ' is now leaving for ' . $sParams . '.'
					);
					
					$this -> save ();
				}
				break;
				
			case $this -> m_aTriggers ['Back']:
			case 'back':
			case 'back.':
				if ($this -> m_aTriggers ['Back'] != $sTrigger && $sParams !== null) {
					// Don't respond to messages like 'back in the days...'
					break;
				}
				
				if ($this -> isAway ($sNickname)) {
					$aPrev = $this -> setBack ($sNickname);
					$this -> send (
						$pBot,
						$sDestination,
						$aPrev ['Nickname'] . ' is back from ' . $aPrev ['Reason'] . ' after ' . $this -> duration ($aPrev ['Time']) . '.'
					);
					
					$this -> save ();
				} else if ($this -> m_aTriggers ['Back'] == $sTrigger) {
					// Don't spam this when they used a non-trigger which activates this function.
					$pBot -> send ('NOTICE ' . $sNickname . ' :Sorry, no entry found for your name. Welcome back anyway.');
				}
				break;
				
			case $this -> m_aTriggers ['Restore']:
				if ($this -> isRestorable ($sNickname)) {
					$aPrev = $this -> restoreBack ($sNickname);
					$this -> send (
						$pBot,
						$sDestination,
						$aPrev ['Nickname'] . ' is away again for ' . $aPrev ['Reason'] . ' and already gone for ' . $this -> duration ($aPrev ['Time']) . '.'
					);
				} else {
					if ($this -> isAway ($sNickname)) {
						$pBot -> send ('NOTICE ' . $sNickname . ' :You are already set as away.');
					} else {
						$pBot -> send ('NOTICE ' . $sNickname . ' :Sorry, could not restore your away entry.');
					}
				}
				break;
				
			case $this -> m_aTriggers ['List']:
				$bNickOnly = false;
				if ($sParams == null) {
					$sSearch = $sNickname;
					$bNickOnly = true;
				} else {
					$sSearch = $sParams;
				}
				
				$aResult = $this -> searchAway ($sSearch, $bNickOnly);
				$nResults = count ($aResult);
				if ($nResults > 0) {
					for ($i = 0; $i < 3 && isset ($aResult [$i]); $i++) {
						$this -> send (
							$pBot,
							$sDestination,
							$aResult [$i]['Nickname'] . ' is currently away for ' . $aResult [$i]['Reason'] . ' and already gone for ' . $this -> duration ($aResult [$i]['Time']) . '.'
						);
					}
					
					if ($nResults > 3) {
						$this -> send (
							$pBot,
							$sDestination,
							$nResults . ' results found for "' . $sSearch . '".'
						);
					}
				} else {
					$this -> send (
							$pBot,
							$sDestination,
							'Nobody found away matching against "' . $sSearch . '"'
						);
				}
				break;
				
			case $this -> m_aTriggers ['Records']:
				$aTimestamps = array ();
				$aAwayCache  = $this -> m_aAwayCache;
				
				foreach ($aAwayCache as $sNickname => $aInfo) {
					if ($sParams != null && stripos ($sNickname, $sParams) === false) {
						unset ($aAwayCache [$sNickname]);
					}
					$aTimestamps [$sNickname] = $aInfo ['Time'];
				}
				array_multisort ($aTimestamps, SORT_NUMERIC, SORT_ASC, $aAwayCache);
				
				$aTop3 = array_slice ($aAwayCache, 0, 3);
				foreach ($aTop3 as $aInfo) {
					$this -> send (
						$pBot,
						$sDestination,
						$aInfo ['Nickname'] . ' is currently away for ' . $aInfo ['Reason'] . ' and already gone for ' . $this -> duration ($aInfo ['Time']) . '.'
					);
				}
				
				break;
		}
	}
	
	/**
	 * Shortcut to the send() method of Bot.
	 * 
	 * @param Bot $pBot The bot to send the message with.
	 * @param string $sDestination The place to send the message to.
	 * @param string $sMessage The actual message which is to be sent.
	 */
	private function send (Bot $pBot, $sDestination, $sMessage) {
		$pBot -> send ('PRIVMSG ' . $sDestination . ' :' . $sMessage);
	}
	
	/**
	 * This method calculates the difference between the given timestamp and the
	 * current time, and returns a nicely formatted string of the difference.
	 * 
	 * @param integer $nTimestamp The timestamp to format the difference of.
	 * @return string
	 */
	private static function duration ($nTimestamp) {
		return Util :: formatTime (time () - $nTimestamp);
	}
	
	/**
	 * Cleans up the given reason. If m_bStripFormat is true, all formatting
	 * will be removed. If sReason is null, it will return a default reason.
	 * 
	 * @param string $sReason A reason for being away.
	 * @return string
	 */
	private function reason ($sReason) {
		if ($this -> m_bStripFormat) {
			$sReason = Util :: stripFormat ($sReason);
		}
		
		if ($sReason === null || $sReason == "") {
			$sReason = 'Away From Keyboard';
		}
		
		return $sReason;
	}
	
	/**
	 * This method updates the away entry of the given person and returns
	 * the previous entry.
	 * 
	 * @param string $sNickname The person who's away.
	 * @param string $sReason The reason of being away.
	 * @return array
	 */
	private function updateAway ($sNickname, $sReason = null) {
		if (!$this -> isAway ($sNickname)) return false;
		
		$aReturn = $this -> setBack ($sNickname);
		$this -> setAway ($sNickname, $sReason);
		
		return $aReturn;
	}
	
	/**
	 * This method sets a given person as away.
	 * 
	 * @param string $sNickname The person who's away.
	 * @param string $sReadon The reason of being away.
	 * @return boolean
	 */
	private function setAway ($sNickname, $sReason = null) {
		if ($this -> isAway ($sNickname)) return false;
		
		$this -> m_aAwayCache [$sNickname] = array
		(
			'Nickname' => $sNickname,
			'IsBack'   => false,
			'Time'     => time(),
			'Reason'   => $sReason
		);
		
		return true;
	}
	
	/**
	 * Sets a person as back by removing his/her entry. Returns the entry 
	 * it removed.
	 * 
	 * @param string $sNickname The person who came back.
	 * @return array
	 */
	private function setBack ($sNickname) {
		if (!$this -> isAway ($sNickname)) return false;
		
		$this -> m_aAwayCache [$sNickname]['IsBack'] = true;
		
		$aReturn = $this -> m_aAwayCache [$sNickname];
		
		return $aReturn;
	}
	
	/**
	 * Restores the away entry for the given person. For "records" or people
	 * who typed the wrong command or something. Hey, it's a feature.
	 * 
	 * @param string $sNickname The person of whom to restore the away for.
	 * @return boolean|array
	 */
	private function restoreBack ($sNickname) {
		if (!$this -> isRestorable ($sNickname)) return false;
		
		$this -> m_aAwayCache [$sNickname]['IsBack'] = false;
		
		return $this -> m_aAwayCache [$sNickname];
	}
	
	/**
	 * Returns a boolean whether the given person is away or not.
	 * 
	 * @param string $sNickname The person to check for.
	 * @return boolean
	 */
	private function isAway ($sNickname) {
		return isset ($this -> m_aAwayCache [$sNickname]) && !$this -> m_aAwayCache [$sNickname]['IsBack'];
	}
	
	/**
	 * Returns whether the last !back command can be undone.
	 * 
	 * @param string $sNickname The person to check for.
	 * @return boolean
	 */
	private function isRestorable ($sNickname) {
		return isset ($this -> m_aAwayCache [$sNickname]) && isset ($this -> m_aAwayCache [$sNickname]['IsBack']) && $this -> m_aAwayCache [$sNickname]['IsBack'] == true;
	}
	
	/**
	 * Returns an array of the people who are currently away. This method
	 * first searches through all names and then through reasons. In the
	 * returned array, all the name results come before any reason result.
	 * 
	 * @param string $sQuery The pattern to search for.
	 * @return array
	 */
	private function searchAway ($sQuery, $bNickOnly = false) {
		$aNameResult = array ();
		$aReasonResult = array ();
		
		foreach ($this -> m_aAwayCache as $sNickname => $aInfo) {
			if ($aInfo ['IsBack']) continue;
			
			if (stripos ($sNickname, $sQuery) !== false) {
				$aNameResult [] = $aInfo;
				continue;
			}
			
			if (!$bNickOnly && stripos ($aInfo ['Reason'], $sQuery) !== false) {
				$aReasonResult [] = $aInfo;
			}
		}
		
		return array_merge ($aNameResult, $aReasonResult);
	}
}
?>